/**   
*  This file is part of WaterfallDisplay.
    
*  WaterfallDisplay is free software: you can redistribute it and/or modify   
*  it under the terms of the GNU General Public License as published by   
*  the Free Software Foundation, either version 3 of the License, or   
*  (at your option) any later version.
     
*  WaterfallDisplay is distributed in the hope that it will be useful,   
*  but WITHOUT ANY WARRANTY; without even the implied warranty of   
*  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the   
*  GNU General Public License for more details.
   
*  See <http://www.gnu.org/licenses/>.

*/  
package waterfall;

import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.awt.image.ColorModel;
import java.awt.image.DataBuffer;
import java.awt.image.DataBufferByte;
import java.awt.image.IndexColorModel;
import java.awt.image.Raster;
import java.awt.image.SampleModel;
import java.awt.image.SinglePixelPackedSampleModel;
import java.awt.image.WritableRaster;
import java.io.ByteArrayInputStream;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.GregorianCalendar;

import pngencoder.PngEncoderB;

import waterfall.Feeder.FeederType;

import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Task;
import javafx.concurrent.Worker;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Point2D;
import javafx.geometry.Rectangle2D;
import javafx.scene.Cursor;
import javafx.scene.Parent;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Line;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.RectangleBuilder;
import javafx.stage.Popup;
import javafx.util.Duration;

/**
 * It shows a waterfall display with time and intensity.
 *  
 * @author altug.uzunali
 *
 */
public class Waterfall extends StackPane {

	private Timeline time = new Timeline();
	private ImageView iv;
	private ColorModel colorModel;
	private int waterFallWidth;
	private int waterFallHeight;
	private int timeStampWidth;
	private int timeStampHeight;	
	private byte[] waterFallPixels;
	private KeyFrame keyFrame;
	private boolean intervalChanged = false;
	private ArrayList<OverlayText> timeStampList = new ArrayList<OverlayText>();
	private ArrayList<OverlayText> overlayTextList = new ArrayList<OverlayText>();
	private int timeStampCounter = 0;
	private int lineCounter = 0;
	private boolean firstTimeStamp = true;
	private int timeStampCycle = 50;
	private final Line hLine;
	private final Line vLine;
	private final ObjectProperty<Color> overlayColorProperty;
	private final ObjectProperty<Color> intervalLineColorProperty;
	private final ObjectProperty<Color> crosshairsColorProperty;
	private final SimpleDoubleProperty crosshairsDashedProperty;
	private final ObjectProperty<Color> firstColorProperty;
	private final ObjectProperty<Color> secondColorProperty;
	private final SimpleIntegerProperty firstLimitProperty;
	private final SimpleIntegerProperty secondLimitProperty;
	private final SimpleIntegerProperty refreshIntervalProperty;
	private final SimpleIntegerProperty intensityProperty;
	private Feeder feeder;
	private Popup popup;
	private Point2D entryPoint;
	private Point2D crosshairsStart;

	public ObjectProperty<Color> overlayColorProperty() {
		return overlayColorProperty;
	}
	
	public ObjectProperty<Color> intervalLineColorProperty() {
		return intervalLineColorProperty;
	}
	
	public ObjectProperty<Color> crosshairsColorProperty() {
		return crosshairsColorProperty;
	}
	
	public ObjectProperty<Color> firstColorProperty() {
		return firstColorProperty;
	}
	
	public ObjectProperty<Color> secondColorProperty() {
		return secondColorProperty;
	}

	public SimpleIntegerProperty refreshIntervalProperty() {
		return refreshIntervalProperty;
	}
	
	public SimpleIntegerProperty firstLimitProperty() {
		return firstLimitProperty;
	}
	
	public SimpleIntegerProperty secondLimitProperty() {
		return secondLimitProperty;
	}
	
	public SimpleIntegerProperty intensityProperty() {
		return intensityProperty;
	}
	
	public SimpleDoubleProperty crosshairsDashedProperty() {
		return crosshairsDashedProperty;
	}

	/**
	 * Constructor for Waterfall
	 * 
	 * @param waterFallWidth is the width of the waterfall
	 * @param waterFallHeight is the height of the waterfall
	 * @param timeStampWidth is the width of the time stamp region
	 * @param interval is the refresh interval time in millisecs
	 */
	@SuppressWarnings({ "unchecked", "rawtypes" })
	public Waterfall(final int waterFallWidth, final int waterFallHeight, final int timeStampWidth, int interval) {
		super();
		this.waterFallWidth = waterFallWidth;
		this.waterFallHeight = waterFallHeight;
		this.timeStampWidth = timeStampWidth;
		this.timeStampHeight = waterFallHeight;
		int waterFallSize = waterFallWidth * waterFallHeight;
		waterFallPixels = new byte[waterFallSize];
		feeder = new Feeder(waterFallWidth, FeederType.Sinusoidal);
		intensityProperty = new SimpleIntegerProperty();
		overlayColorProperty = new SimpleObjectProperty<Color>(Color.WHITE);
		intervalLineColorProperty = new SimpleObjectProperty<Color>(Color.WHITE);
		firstColorProperty = new SimpleObjectProperty<Color>(Color.GRAY);
		secondColorProperty = new SimpleObjectProperty<Color>(Color.YELLOW);
		firstLimitProperty = new SimpleIntegerProperty(2);
		secondLimitProperty = new SimpleIntegerProperty(128);
		crosshairsDashedProperty = new SimpleDoubleProperty(0);
		// Sets the dash property of the cross hairs
		crosshairsDashedProperty.addListener(new ChangeListener() {

			@Override
			public void changed(ObservableValue ov, Object oldValue, Object newValue) {
				if ((Double)newValue > 0.0) {
					hLine.getStrokeDashArray().clear();
					vLine.getStrokeDashArray().clear();
					hLine.getStrokeDashArray().addAll((Double)newValue, (Double)newValue);
					vLine.getStrokeDashArray().addAll((Double)newValue, (Double)newValue);
				}
			}
		});
		crosshairsColorProperty = new SimpleObjectProperty<Color>(Color.LIGHTGREEN);
		// Sets the color property of cross hairs
		crosshairsColorProperty.addListener(new ChangeListener() {

			@Override
			public void changed(ObservableValue ov, Object oldValue, Object newValue) {
				hLine.setStroke((Color) newValue);
				vLine.setStroke((Color) newValue);
			}
		});
		hLine = new Line(waterFallWidth / 2 , 0, waterFallWidth / 2, waterFallHeight);
		hLine.setStroke(crosshairsColorProperty.getValue());
		hLine.setTranslateX(0.5);
		vLine = new Line(timeStampWidth , waterFallHeight / 2, waterFallWidth + timeStampWidth, waterFallHeight / 2);
		vLine.setStroke(crosshairsColorProperty.getValue());
		vLine.setTranslateY(0.5);
		
		crosshairsStart = new Point2D(waterFallWidth / 2, waterFallHeight / 2);
		
		refreshIntervalProperty = new SimpleIntegerProperty(interval);
		// Sets the refresh interval value
		refreshIntervalProperty.addListener(new ChangeListener() {

			@Override
			public void changed(ObservableValue ov, Object oldValue, Object newValue) {
				setInterval((Integer) newValue);
			}
		});

		colorModel = generateColorModel();
		iv = new ImageView();      
		Rectangle2D viewportRect = new Rectangle2D(0, 0, waterFallWidth + timeStampWidth, waterFallHeight);
		iv.setViewport(viewportRect);
		final Rectangle crosshairsPicker = RectangleBuilder.create()
				.width(waterFallWidth)
				.height(waterFallHeight)
				.opacity(0)
				.translateX(timeStampWidth / 2)
				.cursor(Cursor.NONE)
				.build();
	
		crosshairsPicker.setOnMouseMoved(new EventHandler<MouseEvent>() {

			@Override
			public void handle(MouseEvent me) {

				hLine.setTranslateX(me.getX() - crosshairsStart.getX() + 0.5);
				vLine.setTranslateY(me.getY() - crosshairsStart.getY() + 0.5);
				intensityProperty.set((int)waterFallPixels[(int)(me.getY() * waterFallWidth + me.getX())]);
			}
		});
		
		// Entry point is used for the starting position of an overlay text
		entryPoint = new Point2D(0, 0);
		final TextEntry textEntry = new TextEntry();
		textEntry.textProperty().addListener(new ChangeListener<String>() {

			@Override
			public void changed(ObservableValue<? extends String> arg0,
					String arg1, String arg2) {
				overlayTextList.add(new OverlayText(arg2, entryPoint.getX(), entryPoint.getY()));
				if (popup.isShowing())
					popup.hide();
			}
		});
		// Pop up windows is shown when the right button clicked.
		crosshairsPicker.setOnMouseClicked(new EventHandler<MouseEvent>() {

			@Override
			public void handle(MouseEvent me) {
				if (me.getButton() == MouseButton.SECONDARY) {
					Parent parent = Waterfall.this.getParent();
					// Popup will be shown at the clicked position 
					Point2D point = crosshairsPicker.localToScene(0, 0);
					final double layoutX = parent.getScene().getWindow().getX() + parent.getScene().getX() + point.getX() + me.getX();
					final double layoutY = parent.getScene().getWindow().getY() + parent.getScene().getY() + point.getY() + me.getY();
					textEntry.resetText();
					popup.show(Waterfall.this, layoutX, layoutY);
					entryPoint = new Point2D(timeStampWidth + me.getX(), me.getY());
				}
			}
		});
		StackPane crosshairsPane = new StackPane();
		crosshairsPane.setTranslateX(timeStampWidth / 2);

		crosshairsPane.getChildren().addAll(hLine, vLine);
		getChildren().addAll(iv, crosshairsPane, crosshairsPicker);
		
		// Pop up window for text input. It is shown when the right button clicked.
		popup = new Popup();
		popup.setAutoHide(true);
		popup.setAutoFix(true);
		popup.setHideOnEscape(true);
		popup.getContent().add(textEntry);
	
		time.setCycleCount(Timeline.INDEFINITE);
		time.getKeyFrames().add(createKeyFrame(refreshIntervalProperty.getValue()));
		time.play();
	}

	/**
	 * Sets a new key frame when the refresh interval has changed
	 * 
	 * @param refreshInterval is the refresh time for the waterfall
	 */
	private void setInterval(int refreshInterval){
		intervalChanged = true;
		time.stop();
		time.getKeyFrames().removeAll(keyFrame);
		time.getKeyFrames().add(createKeyFrame(refreshInterval));
		time.playFromStart();
	}

	/**
	 * Creates a new key frame
	 * 
	 * @param refreshInterval is the refresh time for the waterfall
	 * @return key frame
	 */
	private KeyFrame createKeyFrame(int refreshInterval){
		keyFrame = new KeyFrame(Duration.millis(refreshInterval), new EventHandler<ActionEvent>() {
			public void handle(ActionEvent event) {
				// Waterfall image is created in the background
				final GenerateWaterfall generateWaterfall = new GenerateWaterfall();
				generateWaterfall.stateProperty().addListener(new ChangeListener<Worker.State>() {

					@Override
					public void changed(ObservableValue<? extends Worker.State> ov,
							Worker.State oldState, Worker.State newState) {
						if (newState == Worker.State.SUCCEEDED) {
							iv.setImage(generateWaterfall.getValue());
						}
					}
				});
				new Thread(generateWaterfall).start();
			}
		});

		return keyFrame;
	}

	/**
	 * Generates a indexed color model
	 *  
	 * @return indexed color model
	 */
	private ColorModel generateColorModel() {
		// Generate 128-color model
		int numColors = 128;
		byte[] r = new byte[numColors];
		byte[] g = new byte[numColors];
		byte[] b = new byte[numColors];

		Color intervalChangeLineColor = intervalLineColorProperty.getValue();
		Color backgroundColor = Color.BLACK;
		Color waterFallFirstColor = firstColorProperty.getValue();
		Color waterFallSecondColor = secondColorProperty.getValue();

		// 0 index is used as background color
		r[0] = (byte)(backgroundColor.getRed() * (numColors - 1)); 
		g[0] = (byte)(backgroundColor.getGreen() * (numColors - 1)); 
		b[0] = (byte)(backgroundColor.getBlue() * (numColors - 1));
		
		// 1 index is used for interval change line
		r[1] = (byte)(intervalChangeLineColor.getRed() * (numColors - 1)); 
		g[1] = (byte)(intervalChangeLineColor.getGreen() * (numColors - 1)); 
		b[1] = (byte)(intervalChangeLineColor.getBlue() * (numColors - 1));

		// Colors used to represent the intensity value between the first color and the second color
		for (int i = firstLimitProperty.getValue(); i < secondLimitProperty.getValue(); i++) {
			float num = (float)i / numColors;
			r[i] = (byte)((waterFallSecondColor.getRed() * num + waterFallFirstColor.getRed() * (1 - num)) * (numColors - 1));
			g[i] = (byte)((waterFallSecondColor.getGreen() * num + waterFallFirstColor.getGreen() * (1 - num)) * (numColors - 1));
			b[i] = (byte)((waterFallSecondColor.getBlue() * num + waterFallFirstColor.getBlue() * (1 - num)) * (numColors - 1));
		}

		return new IndexColorModel(8, numColors, r, g, b);
	}
	
	/**
	 * Creates a buffered image using AWT classes
	 * 
	 * @param data is the data array which holds the representative color values
	 * @param w is the width of the image 
	 * @param h the height of the image
	 * @param colorModel is the indexed color model
	 * @return a buffered image
	 */
	private BufferedImage createImage(byte[] data, int w, int h, ColorModel colorModel){
		DataBuffer dbuf = new DataBufferByte(data, w * h, 0);
		int bitMasks[] = new int[]{(byte)0xff};
		SampleModel sampleModel = new SinglePixelPackedSampleModel(
				DataBuffer.TYPE_BYTE, w, h, bitMasks);
		WritableRaster raster = Raster.createWritableRaster(sampleModel, dbuf, null);

		return new BufferedImage(colorModel, raster, false, null);
	}

	/**
	 * Task class for generating the waterfall image in the background. 
	 * AWT classes are used to create the image. Then PngEncoder converts 
	 * the buffered image to a PNG image. Hence JavaFX Image can handle it. 
	 * 
	 * @author altug.uzunali
	 *
	 */
	private class GenerateWaterfall extends Task<Image>{

		@Override
		protected Image call() throws Exception {
			// Shift the pixels one line
			for (int x = waterFallHeight; x > 1; x--)
				for (int y = 0; y < waterFallWidth; y++) {
					int lower = x * waterFallWidth - 1 - y;
					int upper = lower - waterFallWidth;
					waterFallPixels[lower] = waterFallPixels[upper];
				}
			if (!intervalChanged){
				// Feed last line
				feeder.feed(waterFallPixels);
				
			} else {
				// Draws a line and adds a message if the refresh interval has changed
				for (int i = 0; i < waterFallWidth; i++) {
					waterFallPixels[i] = (byte) 1;
				}
				intervalChanged = false;
				overlayTextList.add(new OverlayText("Refresh interval: " + 
						String.valueOf(refreshIntervalProperty.getValue()) + " ms", timeStampWidth + 5, -2));
			}

			colorModel = generateColorModel();
			// Create a buffered image for intensity data
			BufferedImage waterFallBufferedImage = createImage(waterFallPixels, waterFallWidth, waterFallHeight, colorModel);
			// Create a buffered image for time stamps
			BufferedImage timeStampBufferedImage = createImage(new byte[timeStampWidth * timeStampHeight], timeStampWidth, timeStampHeight, colorModel);

			// Create a full image which will holds the two images
			BufferedImage fullImage = new BufferedImage(waterFallWidth + timeStampWidth, waterFallHeight, BufferedImage.TYPE_INT_ARGB);
			Graphics2D g2d = fullImage.createGraphics();
			g2d.drawImage(timeStampBufferedImage, 0, 0, null);
			g2d.drawImage(waterFallBufferedImage, timeStampWidth, 0, null);
			g2d.clearRect(0, 0, timeStampWidth, timeStampHeight);
			Color overlayColor = overlayColorProperty.getValue();
			g2d.setPaint(new java.awt.Color(
					(int) (overlayColor.getRed() * 255), 
					(int) (overlayColor.getGreen() * 255), 
					(int) (overlayColor.getBlue() * 255)));
			int fontSize = 12;
			g2d.setFont(new Font("Serif", Font.PLAIN, fontSize));
			FontMetrics fm = g2d.getFontMetrics();

			if (timeStampCounter == timeStampCycle || firstTimeStamp){
				String s = (new SimpleDateFormat("HH:mm:ss")).format((new GregorianCalendar()).getTime());
				int x = timeStampWidth - fm.stringWidth(s) - 7;
				int y = fontSize / 2;
				timeStampList.add(new OverlayText(s, x, y));
				timeStampCounter = 0;
				firstTimeStamp = false;
			}

			timeStampCounter++;
			g2d.drawLine(timeStampWidth, 0, timeStampWidth, lineCounter);
			lineCounter++;
			if (lineCounter > timeStampHeight)
				lineCounter = timeStampHeight;
			
			// Draw time stamps
			for (OverlayText stamp : timeStampList){
				stamp.setY(stamp.getY() + 1);
				g2d.drawString(stamp.getText(), stamp.getX(), stamp.getY());
				g2d.drawLine(timeStampWidth, stamp.getY() - fontSize / 2, timeStampWidth - 4, stamp.getY() - fontSize / 2);
			}

			// Draw refresh line messages
			for (OverlayText overlay: overlayTextList){
				overlay.setY(overlay.getY() + 1);
				g2d.drawString(overlay.getText(), overlay.getX(), overlay.getY());
			}
			// Thanks to the PngEncoder class. It converts buffered image to PNG image 
			PngEncoderB end = new PngEncoderB(fullImage);	
			ByteArrayInputStream in = new ByteArrayInputStream(end.pngEncode());
			Image waterfallImage = new Image(in);

			return waterfallImage;
		}
	}
}
